feat(policies): PolicyEvaluation richer decision (reason + matchedRules)#20
Merged
Merged
Conversation
Follow-up #2/5 to the contract-gap series. #16 added the wire-shape fields `reason` and `matchedRules` but had to leave them as nulls / empty lists because the Policy SPI only returned a PolicyDecision enum. This PR promotes the SPI so policies can actually surface a human-readable reason and the list of rules that fired. SPI additions (access-api) -------------------------- - New PolicyEvaluation record: {decision, reason, matchedRules}. Constructed via factory methods (permit/deny/notApplicable/of) so call-sites stay readable and matchedRules is always non-null + immutable. - Policy interface: new `default PolicyEvaluation evaluateDetailed( PolicyContext)`. The default wraps `evaluate(context)` so legacy policies that only implement the coarse entry point still produce a valid PolicyEvaluation (null reason + empty matchedRules). Override evaluateDetailed directly when the policy has detail worth surfacing. - PolicyEvaluator interface: matching `default PolicyEvaluation evaluateDetailed(name, context)`. Default delegates to the coarse evaluate; DefaultPolicyEvaluator overrides it to dispatch through Policy.evaluateDetailed instead. Evaluator (access-core) ----------------------- - DefaultPolicyEvaluator.evaluateDetailed: * policyName == null -> PolicyEvaluation.notApplicable(reason) * unknown policyName -> PolicyEvaluation.notApplicable(reason) * matched policy -> policy.evaluateDetailed(context) Controller (admin-api) ---------------------- - PolicyAdminController.test now calls evaluator.evaluateDetailed and forwards the result's decision / reason / matchedRules into PolicyTestResponse. Policies that don't override evaluateDetailed still respond cleanly (null reason + empty list) — the wire shape is unchanged. - Javadoc on PolicyTestResponse updated to explain the new reason / matchedRules semantics. Backward compatibility ---------------------- - Every existing Policy implementation continues to compile and behave identically — they only have to override `evaluate`, and evaluateDetailed wraps that result automatically. - DefaultPolicyEvaluator.evaluate is unchanged so PolicyEvaluator callers that don't care about reason / matchedRules keep working.
2 tasks
jlc488
added a commit
that referenced
this pull request
Jun 3, 2026
All implementation PRs have merged: export/import (#53), prod fail-fast gating (#54), mirror mode + opt-in user sync (#55), and the admin-ui "Config Sync" page (devslab-kit-admin-ui #20). Flip the status to Accepted (item 6 of the plan) and record the PRs; the standalone how-to guide is the remaining piece. Both language files.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up #2/5. #16 added wire-shape fields
reasonandmatchedRulestoPolicyTestResponsebut had to leave them as nulls / empty lists because thePolicySPI only returned aPolicyDecisionenum. This PR promotes the SPI so policies can actually surface detail.SPI additions (
access-api)PolicyEvaluationrecord:{ decision, reason, matchedRules }. Factory methodspermit(reason, rules)/deny(reason, rules)/notApplicable(reason)/of(decision)keep call-sites readable andmatchedRulesimmutable + non-null.Policy.evaluateDetailed(context)— newdefaultmethod. The default delegates toevaluate(context)so legacy policies that only implement the coarse entry point still produce a validPolicyEvaluation(null reason + empty matched rules). OverrideevaluateDetaileddirectly when the policy has detail worth surfacing.PolicyEvaluator.evaluateDetailed(name, context)— matchingdefault. Delegates toevaluateby default;DefaultPolicyEvaluatoroverrides it to dispatch throughPolicy.evaluateDetailed.Evaluator (
access-core)DefaultPolicyEvaluator.evaluateDetailed:policyName == null→PolicyEvaluation.notApplicable("policyName was null")PolicyEvaluation.notApplicable("no policy registered for name: " + name)policy.evaluateDetailed(context)Controller (
admin-api)PolicyAdminController.testnow callsevaluator.evaluateDetailedand forwardsdecision/reason/matchedRulesintoPolicyTestResponse. Wire shape unchanged — policies that don't overrideevaluateDetailedstill respond cleanly with null reason + empty list.PolicyTestResponseupdated to explain the new semantics (no longer "placeholders today").Backward compatibility
Policyimplementation continues to compile and behave identically — they only have to overrideevaluate, andevaluateDetailedwraps that result automatically.PolicyEvaluator.evaluateis unchanged, so callers that don't care about reason / matchedRules keep working.Test plan
./gradlew buildgreen (55 tasks, all tests pass — DefaultPolicyEvaluatorTest didn't need any change because its policies overrideevaluate)evaluateDetailedto returnpermit("matched-role-admin", List.of("rule-role-admin")), hit/policies/testin the admin UI, verify the reason + matched rules render in the dialog